跳到主要内容

符合 Python 风格的对象

在本章中,我们将开发一个简单的二维欧几里得向量类型

对象表示形式

每门面向对象的语言至少都有一种获取对象的字符串表示形式的标准方式。Python 提供了两种方式。

  • repr():以便于开发者理解的方式返回对象的字符串表示形式。
  • str():以便于用户理解的方式返回对象的字符串表示形式。

在 Python 3 中,__repr____str____format__ 都必须返回 Unicode 字符串(str 类型)。只有 __bytes__ 方法应该返回字节序列(bytes 类型)

构建向量类

示例 9-2 vector2d_v0.py:目前定义的都是特殊方法

from array import array
import math


class Vector2d:
typecode = 'd' # typecode 是类属性,在 Vector2d 实例和字节序列之间转换时使用

def __init__(self, x, y):
self.x = float(x)
self.y = float(y)

def __iter__(self):
'''
定义 __iter__ 方法,把 Vector2d 实例变成可迭代的对象
这样才能拆包(例如,x, y = my_vector)
这个方法的实现方式很简单,直接调用生成器表达式一个接一个产出分量
可以写成 yield self.x; yield.self.y
'''
return (i for i in (self.x, self.y))

def __repr__(self):
'''
__repr__ 方法使用 {!r} 获取各个分量的表示形式,然后插值,构成一个字符串
因为 Vector2d 实例是可迭代的对象,所以 *self 会把 x 和 y 分量提供给 format 函数。
'''
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)

def __str__(self):
return str(tuple(self))

def __bytes__(self):
return (bytes([ord(self.typecode)]) + # 把 typecode 转换成字节序列
bytes(array(self.typecode, self))) # 迭代 Vector2d 实例,得到一个数组,再把数组转换成字节序列

def __eq__(self, other):
return tuple(self) == tuple(other)

def __abs__(self):
return math.hypot(self.x, self.y)

def __bool__(self):
return bool(abs(self))

示例 9-2 中的 __eq__ 方法,在两个操作数都是 Vector2d 实例时可用,不过拿 Vector2d 实例与其他具有相同数值的可迭代对象相比,结果也是 True(如 Vector(3, 4) == [3, 4])。这个行为可以视作特性,也可以视作缺陷。

备选构造方法

我们可以把 Vector2d 实例转换成字节序列了;同理,也应该能从字节序列转换成 Vector2d 实例。在标准库中探索一番之后,我们发现 array.array 有个类方法 .frombytes 正好符合需求。

@classmethod  # 类方法
def frombytes(cls, octets): # 不用传入 self 参数;相反,要通过 cls 传入类本身
typecode = chr(octets[0]) # 从第一个字节中读取 typecode
memv = memoryview(octets[1:]).cast(typecode) # 使用传入的 octets 字节序列创建一个 memoryview,然后使用 typecode 转换
return cls(*memv) # 拆包转换后的 memoryview,得到构造方法所需的一对参数

classmethod 与 staticmethod

classmethod 定义操作类,而不是操作实例的方法。classmethod 改变了调用方法的方式,因此类方法的第一个参数是类本身,而不是实例。

classmethod 最常见的用途是定义备选构造方法,例如上个例子中的 frombytes。注意,frombytes 的最后一行使用 cls 参数构建了一个新实例,即 cls(*memv)

按照约定,类方法的第一个参数名为 cls(但是 Python 不介意具体怎么命名)。

staticmethod 装饰器也会改变方法的调用方式,但是第一个参数不是特殊的值。其实,静态方法就是普通的函数,只是碰巧在类的定义体中,而不是在模块层定义。

示例 9-4 比较 classmethodstaticmethod 的行为

>>> class Demo:
... @classmethod
... def klassmeth(*args):
... return args
... @staticmethod
... def statmeth(*args):
... return args
...
>>> Demo.klassmeth() # 不管怎样调用 Demo.klassmeth,它的第一个参数始终是 Demo 类
(<class '__main__.Demo'>,)
>>> Demo.klassmeth('spam')
(<class '__main__.Demo'>, 'spam')
>>> Demo.statmeth() # Demo.statmeth 的行为与普通的函数相似
()
>>> Demo.statmeth('spam')
('spam',)

格式化显示

内置的 format() 函数和 str.format() 方法把各个类型的格式化方式委托给相应的 .__format__(format_spec) 方法。format_spec 是格式说明符,它是:

  • format(my_obj, format_spec) 的第二个参数,或者
  • str.format() 方法的格式字符串,{} 里代换字段中冒号后面的部分
>>> brl = 1/2.43  # BRL到USD的货币兑换比价
>>> brl
0.4115226337448559
>>> format(brl, '0.4f') # ➊
'0.4115'
>>> '1 BRL = {rate:0.2f} USD'.format(rate=brl) # ➋
'1 BRL = 0.41 USD'

'{0.mass:5.3e}' 这样的格式字符串其实包含两部分

  • 冒号左边的 '0.mass' 在代换字段句法中是字段名
  • 冒号后面的 '5.3e' 是格式说明符。

格式说明符使用的表示法叫格式规范微语言(Format Specification Mini-Language)

格式规范微语言为一些内置类型提供了专用的表示代码。比如,bx 分别表示二进制和十六进制的 int 类型,f 表示小数形式的 float 类型,而 % 表示百分数形式:

>>> format(42, 'b')
'101010'
>>> format(2/3, '.1%')
'66.7%'

格式规范微语言是可扩展的,因为各个类可以自行决定如何解释 format_spec 参数。例如, datetime 模块中的类,它们的 __format__ 方法使用的格式代码与 strftime() 函数一样。下面是内置的 format() 函数和 str.format() 方法的几个示例:

>>> from datetime import datetime
>>> now = datetime.now()
>>> format(now, '%H:%M:%S')
'18:49:05'
>>> "It's now {:%I:%M %p}".format(now)
"It's now 06:49 PM"

如果类没有定义 __format__ 方法,从 object 继承的方法会返回 str(my_object)。我们为 Vector2d 类定义了 __str__ 方法,因此可以这样做:

>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'

我们将实现自己的微语言来解决这个问题。首先,假设用户提供的格式说明符是用于格式化向量中各个浮点数分量的。我们想达到的效果是:

>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
>>> format(v1, '.2f')
'(3.00, 4.00)'
>>> format(v1, '.3e')
'(3.000e+00, 4.000e+00)'

实现这种输出的 __format__ 方法如示例 9-5 所示。

示例 9-5 Vector2d.__format__ 方法,第 1 版

# 在 Vector2d 类中定义
def __format__(self, fmt_spec=''):
# 使用内置的 format 函数把 fmt_spec 应用到向量的各个分量上,构建一个可迭代的格式化字符串
components = (format(c, fmt_spec) for c in self)
# 把格式化字符串代入公式 '(x, y)' 中
return '({}, {})'.format(*components)

下面要在微语言中添加一个自定义的格式代码:如果格式说明符以 'p' 结尾,那么在极坐标中显示向量,即 <r, θ >,其中 r 是模,θ(西塔)是弧度;其他部分('p' 之前的部分)像往常那样解释

为自定义的格式代码选择字母时,最好避免使用其他类型用过的字母。

在格式规范微语言中,整数使用的代码有 'bcdoxXn',浮点数使用的代码有 'eEfFgGn%',字符串使用的代码有 's'

因此,为极坐标选的代码是 'p'

各个类使用自己的方式解释格式代码,在自定义的格式代码中重复使用代码字母不会出错,但是可能会让用户困惑。

对极坐标来说,我们已经定义了计算模的 __abs__ 方法,因此还要定义一个简单的 angle 方法,使用 math.atan2() 函数计算角度:

# 在Vector2d类中定义
def angle(self):
return math.atan2(self.y, self.x)

这样便可以增强 __format__ 方法,计算极坐标,如示例 9-6 所示。

示例 9-6 Vector2d.__format__ 方法,第 2 版,现在能计算极坐标了

def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = '<{}, {}>'
else:
coords = self
outer_fmt = '({}, {})'
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
>>> format(Vector2d(1, 1), 'p')
'<1.4142135623730951, 0.7853981633974483>'
>>> format(Vector2d(1, 1), '.3ep')
'<1.414e+00, 7.854e-01>'
>>> format(Vector2d(1, 1), '0.5fp')
'<1.41421, 0.78540>'

如本节所示,为用户自定义的类型扩展格式规范微语言并不难

可散列化

按照定义,目前 Vector2d 实例是不可散列的,因此不能放入集合(set)中:

>>> v1 = Vector2d(3, 4)
>>> hash(v1)
Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
>>> set([v1])
Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'

为了把 Vector2d 实例变成可散列的,必须使用 __hash__ 方法(还需要 __eq__ 方法,前面已经实现了)。此外,还要让向量不可变

目前,我们可以为分量赋新值,如 v1.x = 7Vector2d 类的代码并不阻止这么做。我们想要的行为是这样的:

>>> v1.x, v1.y
(3.0, 4.0)
>>> v1.x = 7
Traceback (most recent call last):
...
AttributeError: can't set attribute

为此,我们要把 xy 分量设为只读特性

示例 9-7 vector2d_v3.py:这里只给出了让 Vector2d 不可变的代码,完整的代码清单在示例 9-9 中

class Vector2d:
typecode = 'd'

def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)

@property
def x(self):
return self.__x

@property
def y(self):
return self.__y

def __iter__(self):
return (i for i in (self.x, self.y))

# 其他方法

注意,我们让这些向量不可变是有原因的,因为这样才能实现 __hash__ 方法。这个方法应该返回一个整数,理想情况下还要考虑对象属性的散列值(__eq__ 方法也要使用),因为相等的对象应该具有相同的散列值。

最好使用位运算符异或(^)混合各分量的散列值 —— 我们会这么做。Vector2d.__hash__ 方法的代码十分简单,如示例 9-8 所示。

示例 9-8 vector2d_v3.py:实现 __hash__ 方法

# 在 Vector2d 类中定义
def __hash__(self):
return hash(self.x) ^ hash(self.y)

添加 __hash__ 方法之后,向量变成可散列的了:

>>> v1 = Vector2d(3, 4)
>>> v2 = Vector2d(3.1, 4.2)
>>> hash(v1), hash(v2)
(7, 384307168202284039)
>>> set([v1, v2])
{Vector2d(3.1, 4.2), Vector2d(3.0, 4.0)}

要想创建可散列的类型,不一定要实现特性,也不一定要保护实例属性。只需正确地实现 __hash____eq__ 方法即可。但是,实例的散列值绝不应该变化,因此我们借机提到了只读特性。

Python 的私有属性和“受保护的”属性

Python 有个简单的机制,能避免子类意外覆盖“私有”属性。

举个例子。有人编写了一个名为 Dog 的类,这个类的内部用到了 mood 实例属性,但是没有将其开放。现在,你创建了 Dog 类的子类:Beagle。如果你在毫不知情的情况下又创建了名为 mood 的实例属性,那么在继承的方法中就会把 Dog 类的 mood 属性覆盖掉。这是个难以调试的问题。

为了避免这种情况,如果以 __mood 的形式(两个前导下划线,尾部没有或最多有一个下划线)命名实例属性,Python 会把属性名存入实例的 __dict__ 属性中,而且会在前面加上一个下划线和类名。因此,对 Dog 类来说,__mood 会变成 _Dog__mood;对 Beagle 类来说,会变成 _Beagle__mood。这个语言特性叫名称改写(name mangling)。

示例 9-10 私有属性的名称会被“改写”,在前面加上下划线和类名

>>> v1 = Vector2d(3, 4)
>>> v1.__dict__
{'_Vector2d__y': 4.0, '_Vector2d__x': 3.0}
>>> v1._Vector2d__x
3.0

名称改写是一种安全措施,不能保证万无一失:它的目的是避免意外访问,不能防止故意做错事(图 9-1 也是一种保护装置)。

如示例 9-10 中的最后一行所示,只要知道改写私有属性名的机制,任何人都能直接读取私有属性——这对调试和序列化倒是有用。此外,只要编写 v1._Vector__x = 7 这样的代码,就能轻松地为 Vector2d 实例的私有分量直接赋值。如果真在生产环境中这么做了,出问题时可别抱怨。

不是所有 Python 程序员都喜欢名称改写功能,也不是所有人都喜欢 self.__x 这种不对称的名称。有些人不喜欢这种句法,他们约定使用一个下划线前缀编写“受保护”的属性(如 self._x)。批评使用两个下划线这种改写机制的人认为,应该使用命名约定来避免意外覆盖属性。

Python 解释器不会对使用单个下划线的属性名做特殊处理,不过这是很多 Python 程序员严格遵守的约定,他们不会在类外部访问这种属性。遵守使用一个下划线标记对象的私有属性很容易,就像遵守使用全大写字母编写常量那样容易。

不过在模块中,顶层名称使用一个前导下划线的话,的确会有影响:对 from mymod import * 来说,mymod 中前缀为下划线的名称不会被导入。然而,依旧可以使用 from mymod import _privatefunc 将其导入。

Python 文档的某些角落把使用一个下划线前缀标记的属性称为“受保护的”属性。9 使用 self._x 这种形式保护属性的做法很常见,但是很少有人把这种属性叫作“受保护的”属性。有些人甚至将其称为“私有”属性。

⭐ 使用 slots 类属性节省空间

默认情况下,Python 在各个实例中名为 __dict__ 的字典里存储实例属性。为了使用底层的散列表提升访问速度,字典会消耗大量内存。

如果要处理数百万个属性不多的实例,通过 __slots__ 类属性,能节省大量内存,方法是让解释器在元组中存储实例属性,而不用字典

继承自超类的 __slots__ 属性没有效果。Python 只会使用各个类中定义的 __slots__ 属性

定义 __slots__ 的方式是,创建一个类属性,使用 __slots__ 这个名字,并把它的值设为一个字符串构成的可迭代对象,其中各个元素表示各个实例属性。推荐使用元组,因为这样定义的 __slots__ 中所含的信息不会变化:

class Vector2d:
__slots__ = ('__x', '__y')

typecode = 'd'

在类中定义 __slots__ 属性的目的是告诉解释器:“这个类中的所有实例属性都在这儿了!”这样,Python 会在各个实例中使用类似元组的结构存储实例变量,从而避免使用消耗内存的 __dict__ 属性。如果有数百万个实例同时活动,这样做能节省大量内存。

在类中定义 __slots__ 属性之后,实例不能再有 __slots__ 中所列名称之外的其他属性。这只是一个副作用,不是 __slots__ 存在的真正原因。不要使用 __slots__ 属性禁止类的用户新增实例属性。__slots__ 是用于优化的,不是为了约束程序员。

然而,“节省的内存也可能被再次吃掉”:如果把 '__dict__' 这个名称添加到 __slots__ 中,实例会在元组中保存各个实例的属性,此外还支持动态创建属性,这些属性存储在常规的 __dict__ 中。当然,把 '__dict__' 添加到 __slots__ 中可能完全违背了初衷,这取决于各个实例的静态属性和动态属性的数量及其用法。粗心的优化甚至比提早优化还糟糕。

此外,还有一个实例属性可能需要注意,即 __weakref__ 属性,为了让对象支持弱引用,必须有这个属性。用户定义的类中默认就有 __weakref__ 属性。可是,如果类中定义了 __slots__ 属性,而且想把实例作为弱引用的目标,那么要把 '__weakref__' 添加到 __slots__ 中。

处理列表数据时 __slots__ 属性最有用,例如模式固定的数据库记录,以及特大型数据集。

总之,如果使用得当,__slots__ 能显著节省内存,不过有几点要注意。

  • 每个子类都要定义 __slots__ 属性,因为解释器会忽略继承的 __slots__ 属性。
  • 实例只能拥有 __slots__ 中列出的属性,除非把 '__dict__' 加入 __slots__ 中(这样做就失去了节省内存的功效)。
  • 如果不把 '__weakref__' 加入 __slots__,实例就不能作为弱引用的目标。

如果你的程序不用处理数百万个实例,或许不值得费劲去创建不寻常的类,那就禁止它创建动态属性或者不支持弱引用。与其他优化措施一样,仅当权衡当下的需求并仔细搜集资料后证明确实有必要时,才应该使用 __slots__ 属性。

覆盖类属性

Python 有个很独特的特性:类属性可用于为实例属性提供默认值Vector2d 中有个 typecode 类属性,__bytes__ 方法两次用到了它,而且都故意使用 self.typecode 读取它的值。因为 Vector2d 实例本身没有 typecode 属性,所以 self.typecode 默认获取的是 Vector2d.typecode 类属性的值。

但是,如果为不存在的实例属性赋值,会新建实例属性。假如我们为 typecode 实例属性赋值,那么同名类属性不受影响。然而,自此之后,实例读取的 self.typecode 是实例属性 typecode,也就是把同名类属性遮盖了。借助这一特性,可以为各个实例的 typecode 属性定制不同的值。

Vector2d.typecode 属性的默认值是 'd',即转换成字节序列时使用 8 字节双精度浮点数表示向量的各个分量。如果在转换之前把 Vector2d 实例的 typecode 属性设为 'f',那么使用 4 字节单精度浮点数表示各个分量,如示例 9-13 所示。

示例 9-13 设定从类中继承的 typecode 属性,自定义一个实例属性

>>> from vector2d_v3 import Vector2d
>>> v1 = Vector2d(1.1, 2.2)
>>> dumpd = bytes(v1)
>>> dumpd
b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@'
>>> len(dumpd)
17
>>> v1.typecode = 'f'
>>> dumpf = bytes(v1)
>>> dumpf
b'f\xcd\xcc\x8c?\xcd\xcc\x0c@'
>>> len(dumpf)
9
>>> Vector2d.typecode
'd'

现在你应该知道为什么要在得到的字节序列前面加上 typecode 的值了:为了支持不同的格式

如果想修改类属性的值,必须直接在类上修改,不能通过实例修改。如果想修改所有实例(没有 typecode 实例变量)的 typecode 属性的默认值,可以这么做:

>>> Vector2d.typecode = 'f'

然而,有种修改方法更符合 Python 风格,而且效果持久,也更有针对性。类属性是公开的,因此会被子类继承,于是经常会创建一个子类,只用于定制类的数据属性。Django 基于类的视图就大量使用了这个技术。具体做法如示例 9-14 所示。

示例 9-14 ShortVector2dVector2d 的子类,只用于覆盖 typecode 的默认值

>>> from vector2d_v3 import Vector2d
>>> class ShortVector2d(Vector2d):
... typecode = 'f'
...
>>> sv = ShortVector2d(1/11, 1/27)
>>> sv
ShortVector2d(0.09090909090909091, 0.037037037037037035)
>>> len(bytes(sv)) # 确认得到的字节序列长度为 9 字节,而不是之前的 17 字节
9